LLM 後端最常見的成本事故不是單價貴,是沒有任何一層在花錢之前說不:一個迴圈重試、一段失控的長回覆、一場沒人記得關的對話,帳單月底才告訴你。今天要把成本從「月底的驚嚇」變成「後端的合約」:每一輪呼叫回報它花了多少、每一段對話有生命週期預算、超額的請求在碰到上游之前就被擋下。讀完你會有兩道可執行的花費邊界,和一份三週實測的第一手帳單當基準。
先交代這份帳單,因為它是今天所有設計的背景。
這個系列從 Day 4 開通 Azure OpenAI 資源到今天,所有里程碑的 live smoke test 都打真模型(gpt-5-mini,japaneast,GlobalStandard)。用 Cost Management 查詢 2026-07-01 到 07-22 的實際帳單(az rest 打 Cost Management Query API,同時抓花費與計量數量,2026-07-22 查詢;成本資料入帳通常滯後數小時到一天,07-21/22 的大量 smoke 用量尚未入帳,所以下面的量看起來很小):
| Meter | 計量數量(1M tokens 為單位) | 花費 (TWD) | 佔比 |
|---|---|---|---|
| GPT 5 Mini Input | 0.000062(= 62 tokens) | 0.0005 | 2% |
| GPT 5 Mini Output | 0.00037(= 370 tokens) | 0.0238 | 98% |
| 合計 | 0.0243 |
「98% 是 output」描述的是這個時間窗已入帳的 meter 佔比,不代表一般 workload 的通則,但它背後的兩個機制(單價差與 reasoning 計費)是通則。
忍喵:「三週、九個 milestone、新台幣兩分錢——每個 create script 都帶 teardown、每個資源都活不過它的用途。錢是治理出來的,不是求出來的。」
兩分錢當然便宜,但這張表上有一個不成比例的數字值得停下來:98% 的花費是 output。我們的 smoke test 輸入都比輸出長(整段 replay history 進 input),為什麼帳單反過來?兩個原因疊加:
第一點可以跟牌價互相驗證:官方定價頁的 gpt-5-mini Global 牌價是 $0.25 input/$2.00 output per 1M tokens(Azure OpenAI 定價頁,查核 2026-07),比值一致,隱含匯率約 32 也合理。你的實際費率,用同一招從 Cost Management 反推最不會錯。
第二點用今天的 live smoke 立刻可以看到。請模型「Reply with exactly: pong」,回覆四個字母;為了把 hidden reasoning 攤在帳面上,我們把 usage.output_tokens_details.reasoning_tokens 也接了進來。同參數打數次,其中一次的 usage(從完整 SDK usage 中節錄本文相關欄位;數值逐欄對應留存的 evidence,30 + 79 = 109 自洽):
{"input_tokens": 30, "output_tokens": 79,
"output_tokens_details": {"reasoning_tokens": 64}, "total_tokens": 109}
79 個 output tokens 裡,64 個是 reasoning;可見的「pong」加上結構開銷只佔 15 個。
同一批的另一次同參數呼叫則回報 output_tokens: 64、reasoning_tokens: 0(30 + 64 = 94,同樣自洽):一樣是一個字的回覆,aggregate 高達 64、detail 卻說沒有 reasoning,這次歸因不出來。這個對比本身就是重要教訓:usage 是 request 層級的觀測訊號,detail 是歸因線索;計費以 aggregate meter 為準,兩者不保證每次都能對到帳。
忍喵:「回覆 4 個字母,output 79 tokens、64 個是 reasoning——你付錢的大宗是模型『想』的部分,而它連一個字都沒給你看。估 token 的人估得到這個?」
面對成本,多數教學的直覺是拿 tiktoken 之類的 tokenizer 在本地估 token 數。我們否決了這條路,理由有三:
usage block:input_tokens、output_tokens(含 output_tokens_details.reasoning_tokens)、total_tokens。usage 的定位要先講清楚:它是 request 層級的計量訊號,適合歸因與 guardrail;帳務的權威永遠是發票與 Cost Management 的 meter 紀錄(官方成本管理文件明言以 invoice 為 source of truth,查核 2026-07),別把它包裝成可逐筆對帳的財務資料。
所以原則是:metering over estimation。應用程式不猜任何數字,只負責把上游回報的訊號送到三個該去的地方:API contract、log、與預算的帳本。
第一個去處是 API contract。/api/v1/chat 的回應多了三個欄位:usage(含 reasoning_tokens)、status 與 incomplete_reason。今天的 live smoke:
{
"message": "pong",
"conversation_id": "d955dc7e-92f6-4dd3-b000-fe1508846e7d",
"correlation_id": "day09-fix-2",
"usage": {"input_tokens": 30, "output_tokens": 57, "total_tokens": 87, "reasoning_tokens": 0},
"status": "completed",
"incomplete_reason": null
}
status/incomplete_reason 是把 Day 6 streaming 終端事件的語意鏡射到非串流端點。這其實是實作 max_output_tokens 時撞到的一個必答題:cap 也作用在非串流呼叫上,但 /chat 原本的 contract 沒有任何欄位能說「這個回覆被截斷了」。不處理的話,截斷會被偽裝成正常成功回給 client、還照常 commit 進歷史。
所以規則整組搬過來:max_output_tokens 截斷 client 保留部分文字、turn 照常 commit;content_filter/other client 必須丟棄、turn 不 commit(首輪拿到的 conversation_id 也就從未存在,與 streaming header id 的 provisional 語意一致)。用 LLM_MAX_OUTPUT_TOKENS=16 對真模型逼出來的截斷長這樣(連 16 個 tokens 都不夠模型「想」,可見文字是空的,但 contract 誠實說了):
{"message": "", "status": "incomplete", "incomplete_reason": "max_output_tokens",
"usage": {"input_tokens": 32, "output_tokens": 0, "total_tokens": 32, "reasoning_tokens": 0}}
streaming 端 usage 掛在 message.done 終端事件上;delta 不帶 usage,因為只有終端回應才結算這一輪的量:
event: message.done
data: {"status": "completed", "correlation_id": "day09-final-s", "usage": {"input_tokens": 30, "output_tokens": 51, "total_tokens": 81, "reasoning_tokens": 0}}
這是 Day 6 詞彙表設計還債的時刻:當時定下「client 必須忽略未知欄位與事件名」,所以 usage 是 additive change,舊 client 一行都不用改。
多輪對話下 input 會逐輪變大:store=False 之下每輪重送全部 replay history(Day 7)。若每輪新增內容量大致固定、每輪完整重送歷史,而且沒有截斷或 compaction,單輪 input tokens 會隨輪數線性增加,累計 input tokens 則近似二次成長。這就是預算要以「對話」為單位的原因,下一節回來。
第二個去處是 log。adapter 對每個帶回 usage 的終端寫一行,也就是非串流成功與 stream 的 completed/incomplete(services/azure_openai.py):
llm usage input_tokens=30 output_tokens=57 reasoning_tokens=0 total_tokens=87 correlation_id=day09-fix-2
它跟 Day 8 的 prompt-attribution 行(prompt_name=… prompt_version=… correlation_id=…)用同一個 correlation id join:事故時你可以回答「這個 request 用了哪版 prompt、量到多少 token」,而 client 端拿到的 usage 也對齊同一個 id。
範圍要說老實話:這條歸因鏈只覆蓋「有 usage 終端可讀」的呼叫。response.failed、SDK exception、client 斷線這些路徑可能已經在上游發生了可計費的處理,但沒有終端 usage 可記——log 行缺席不代表零成本,對帳仍以 Cost Management 為準。另外它是歸因不是聚合,「這個月哪個 prompt 版本最燒錢」是 Cost Management 與 Day 27 Application Insights 的工作,不是 grep 的。
有了計帳,才談得上邊界。這個 backend 架了兩道,作用的尺度不同:
| 機制 | 邊界尺度 | 觸發時的行為 |
|---|---|---|
max_output_tokens(LLM_MAX_OUTPUT_TOKENS,預設 1000) |
單次回覆 | 兩個端點都回報 incomplete/max_output_tokens(streaming 走 message.done、非串流走 status 欄位);client 保留部分文字(Day 6 規則) |
conversation token budget(CONVERSATION_TOKEN_BUDGET,預設 50000) |
整段對話 | 429 token_budget_exceeded,在呼叫上游之前拒絕 |
第一道很便宜:每次上游呼叫都帶 max_output_tokens。沒有上限的回覆是燒預算最快的方式(尤其 reasoning tokens 也算在這個上限內),而 Day 6 早就為「被截斷」定好了詞彙,上一節也把它鏡射到了非串流端點。當時是理論上的路徑,今天起是會真實發生的路徑。順帶一提,兩個設定都做 fail-fast 驗證:0 或負值在啟動就被拒絕(負的 cap 會把部署錯誤推遲成難解的上游 400),None 是關閉預算的唯一寫法。
第二道是今天的主角。每段對話有一本帳(Conversation.total_tokens),每輪 turn-commit 時把上游回報的 total_tokens 一起寫入——帳與 turn 在同一次 append 裡原子提交,不會出現「歷史寫進去了、帳漏了」的漂移。
檢查則發生在新一輪 inference 之前(services/conversation.py):
def _check_budget(self, conversation: Conversation) -> None:
# Post-paid ledger, pre-paid gate: the check reads what committed turns
# actually reported, so it can only fire *between* turns — a single turn
# can still overshoot the line by up to one call's worth of tokens
# (bounded by max_output_tokens plus the history the turn replays).
if self._token_budget is None:
return
if conversation.total_tokens >= self._token_budget:
raise TokenBudgetExceededError(
conversation.id, conversation.total_tokens, self._token_budget
)
guardrail 的重點在「之前」兩個字:被拒絕的那一輪不會發出模型 inference 呼叫。單元測試用假的 chat service 證明這條控制流:超額對話再送一輪,service 的呼叫次數維持不變。
被拒絕的請求走 Day 3 以來的同一個 error envelope:
{
"error": {
"code": "token_budget_exceeded",
"message": "This conversation's token budget is exhausted; start a new conversation."
},
"correlation_id": "443bb065-7e3e-4e11-b65c-a9ba4c15f4de"
}
三個語意細節值得說明白:
Retry-After。這個預算是對話的生命週期上限,不隨時間回補。等待不是解法,開新對話才是。跟上游 quota 的 429(我們映射成 503 upstream_throttled,那是「稍後再試」)語意不同,所以是兩個不同的 error code。max_output_tokens 加上該輪重送的歷史)。要「絕不越線」就得回頭做 pre-paid 估算,而那條路的問題上一節講過了。工程上這是個划算的交換:精度讓一步,換到不猜數字、不養 tokenizer。超額的三種反應裡,我們選了兩種:對話尺度 reject(429)、回覆尺度 truncate(max_output_tokens)。第三種 degrade(接近預算時換便宜模型或縮 context)這次明確不做,理由是它需要的基礎設施今天都還不存在:路由到第二個 deployment、對「品質下降」的產品決策、以及讓 client 知道「這個回覆是降級版」的 contract 欄位。在單一模型、單一 prompt 的現階段,degrade 是過度設計;等 Day 16 之後有多模型路由的場景,它才有掛載點。
同樣列出來就不做的還有 per-user quota:現在的 API 沒有身份(Day 19 才有認證),「per-user」在技術上不存在。對話預算是沒有身份時能做到的最誠實粒度:它至少保證單一對話的無限成長會被止住。
content_filter 回覆、斷線,這些路徑可能已花掉 token,帳本與 log 卻不知道。我們選擇讓 turn-commit 優先,並把邊界說清楚:權威的花費紀錄是 Cost Management,ledger 的職責是止血,不是財務對帳。append 的條件寫入契約已備好),ledger 隨對話一起遷移。最後把邊界放回全景,而且要先拆一個危險的誤解:Azure 的 budget alert 不是花費上限。官方文件寫得很直白:超過門檻只會發通知,「資源不受影響、消費不會停止」,而且成本資料本身就滯後數小時、預算每 24 小時才評估一次(官方 budgets 文件,查核 2026-07)。
要真的自動停用資源,得另外接 action group automation,那有自己的失敗模式,本系列不做。
所以三層的分工是:應用程式的 guardrail 管燒錢速率(per-call、per-conversation,即時、在花錢之前);deployment quota(TPM)管吞吐(但限流不是限費);訂閱層的 budget alert(本系列 Day 4 起的 US$20 genai-lab-monthly-cap)是延遲的偵測底線,它不會替你擋下任何一塊錢,只保證失控不會無聲無息地跑完一整個月。
day-09 tag(repo,CI 綠):
usage(含 reasoning_tokens)進 /chat body 與 message.done(additive),與 log 行以 correlation id 對齊/chat 新增 status/incomplete_reason:非串流端點鏡射 Day 6 終端語意,截斷不再能偽裝成功;commit 規則同步(content_filter/other 不 commit)max_output_tokens 上每一次上游呼叫;conversation token budget 於 inference 前檢查,429 token_budget_exceeded envelope;兩個設定 0/負值啟動即拒chat-mini:usage 對齊、tiny budget 第二輪 429、LLM_MAX_OUTPUT_TOKENS=16 逼出真實 incomplete/max_output_tokens
用到的 Azure 服務:Azure OpenAI(gpt-5-mini via Responses API)、Azure Cost Management(帳單查詢與 budget alert)。
下一篇是這條防線的向外一層:application guardrail 管的是「自己人好好花錢」,擋不了 key 外洩後別人幫你花。Day 10 用 Azure API Management 把 API key 收進閘道後面,加上真正 per-client 的 rate limiting。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。